Runner.php

<?php

namespace Tlf\Tester;

class Runner extends \Tlf\Cli {

    public $server_port = [];

    /**
     * Load all commands onto the cli
     * @param $cli \Tlf\Cli cli lib
     */
    public function setup_commands(\Tlf\Cli $cli){
        $commands = [
            // command => [method_name, Help Text]
            'main'=>['run_dir', "Run Tests"],
            'init' => ['init', "Initialize test directory"],
            'server' => ['start_server', "Start test server. Optionally pass configured server name as first arg."],
            'print-config' => ['print_config', "Print runtime configuration (on-disk + defaults + cli args]"],

        ];

        foreach ($commands as $command => $method_help){
            $cli->load_command($command, [$this, $method_help[0]], $method_help[1]);
        }
    }

    /**
     * Initialize the runner and return array of cli arguments, 
     *
     * @param $cli \Tlf\Cli cli lib
     * @param $args array of cli args + configs
     * @return array of args to use for cli
     */
    public function initialize(\Tlf\Cli $cli, array $args): array {
    
        return $args;
    }

    /**
     * Load config file from disk
     * 
     * @param $cli \Tlf\Cli cli lib
     * @param $dir working directory from which to load a config file
     * @param $args array of cli args + configs
     */
    public function load_configs(\Tlf\Cli $cli, string $dir, array $args){
        $locations = [
            '.config/phptest.json',
            'config/phptest.json',
            'test/config.json',
        ];
        foreach ($locations as $file){
            $path = $dir.'/'.$file;
            if (is_file($path)){
                $configs = json_decode(file_get_contents($path),true);
                if (!is_array($configs)){
                    error_log("File '$file' does not contain valid json");
                } else {
                    $cli->load_inputs($configs);
                }
            }
        }
    }

    /**
     * get the server host 
     * @if called by `phptest server`, @then generate random port & write to file
     * @if called by running `->get()` tests, @then load port from file
     *
     */
    public function get_server_host(string $name='main'): string{

        $host = $this->args['host.override'] ?? $this->args['server'][$name]['host'] ?? 'http://localhost';
        if ($host=='http://localhost')$host .= ":".$this->get_host_port($name);
        else if ($host=='https://localhost')$host .= ":".$this->get_host_port($name);

        return $host;
    }

    public function get_host_port(string $server_name): int {
        if (!isset($this->args['server'][$server_name])){
            $this->report("Server '$server_name' is not configured. Cannot create/load host port.");
            throw new \Exception("Server '$server_name' is not configured. Cannot create/load host port.");
            return 0;
        }
        $server = $this->args['server'][$server_name];
        $dir = $this->pwd.'/'.$server['dir'];
        $port_file = $dir.'/.phptest-port';
        if (file_exists($port_file)){
            return (int)trim(file_get_contents($port_file));
        }


        if ($this->command != 'server'){
            $dir_name = $server['dir'];
            $msg = "Host is not stored. File '$dir_name/.phptest-port' should contain an integer port number.";
            $this->report($msg);
            throw new \Exception($msg);
        }

        $port = random_int(3001, 5000);
        file_put_contents($port_file, $port);
        return $port;
    }

    public function print_config(\Tlf\Cli $cli, array $args){
        echo "\n\n";
        echo json_encode($args, JSON_PRETTY_PRINT);
        echo "\n\n";
    }

    /**
     * Start a localhost server for testing
     * @warning this early implementation will change
     */
    public function start_server(\Tlf\Cli $cli, array  $args){
        $server_name = $args['--'][0] ?? $args['server.main'];
        if (!isset($args['server'][$server_name])){
            $this->report("Test server '$server_name' not configured.");
            return false;
        }
        $server = $args['server'][$server_name];
        $host = $this->get_server_host($server_name);
        $dir = $server['dir'];

        $dir = str_replace("//","/", $dir);
        if (substr($dir,0,1)=='/')$dir= substr($dir,1);
        if ($dir == "")$dir = "./";

        $deliver = $dir.'/'.($server['deliver'] ?? 'deliver.php');
        $deliver = str_replace("//","/", $deliver);

        $bootstrap = $dir.'/'.($server['bootstrap'] ?? 'bootstrap.php');

        $deliver_absolute = getcwd().'/'.$deliver;

        //var_dump($host);
        //var_dump($deliver);

        if (!file_exists($deliver_absolute)){
            $this->report("No Deliver script found for server '$server_name'");
            return false;
        }

        if (file_exists($bootstrap)){
            $this->report("`require()` $bootstrap");
            require($bootstrap);
        }

        $host = str_replace(["http://", "https://"], "", $host);

        $command = "php -S $host -t $dir $deliver";

        $this->report("Execute:\n  $command\n\n");
        system($command);
    }

    /**
     * Copies sample test files into `getcwd().'/test'`
     */
    public function init(\Tlf\Cli $cli, array $args){
        $dir = getcwd().'/test';
        $continue = readline("Initialize test dir at $dir? (y/n) ");
        if ($continue!=='y')return;
        mkdir($dir);
        mkdir($dir.'/run');
        mkdir($dir.'/src');
        mkdir($dir.'/input');
        mkdir($dir.'/Server');
        mkdir($dir.'/input/Compare');
        $files = [
            'run/Compare.php',
            'run/Server.php',
            "Tester.php",
            'input/Compare/Compare.php',
            'input/Compare/Compare.txt',
            'Server/deliver.php',
            'bootstrap.php',
            'config.json',
        ];
        foreach ($files as $f){
            $dest = $dir.'/'.$f;
            if (file_exists($dest)){
                echo "\nSkip: $dest";
            } else {
                echo "\nCreate: $dest";
                copy(dirname(__DIR__).'/test/'.$f, $dir.'/'.$f);
            }
        }
    }

    /**
     * Require every php file within a directory
     * @param $path string absolute path to directory
     */
    public function require_directory(string $path){
        foreach (scandir($path) as $f){
            if ($f=='.'||$f=='..')continue;
            if (is_dir($path.'/'.$f)){
                $this->require_directory($path.'/'.$f);
            } else if (substr($f,-4)=='.php'){
                (function() use ($path, $f){
                    require_once($path.'/'.$f);
                })();
            }
        }
    }

    /**
     * executes all tests inside test directories (config `dir.test`)
     *
     * @param $cli 
     * @param $args
     *
     * @return array<string key, mixed value> test info w/ entries pass, fail, disabled, tests_run, class, assersions_pass, assersions_fail
     *
     */
    public function run_dir(\Tlf\Cli $cli, array $args): array {
        $info = [
            'pass'=>0,
            'fail'=>0,
            'tests_run'=>0,
            'disabled'=>0,
            'assertions_pass'=>0,
            'assertions_fail'=>0,
            'failed_tests' => '',
        ];
        $dir = $cli->pwd;

        /** @config file.require array<int index, string rel_file_name> - Array of files to require before running tests.  */
        $required_files = $args['file.require'];
        /** @config dir.test string - relative path to directory containing tests */
        $dir_to_test = $args['dir.test'];
        if (is_string($dir_to_test)){
            error_log("test config 'dir.test' should be an array, not a string");
            $dir_to_test = [$dir_to_test];
        }
        /** @config dir.exclude array<int index, string rel_dir_path> - relative directory paths within the test dir that should not be run. Relative to current working directory. */
        $dirs_to_exclude = $args['dir.exclude'];
        /** @argv class array<int index, string class_name> - Classes to test. If set, no other classes will be run. If not set, all test classes are run */
        $classes_to_test = $args['class'] ?? [];
        // @bugfix for some reason args['class'] isn't an array ... it should be... idk when I broke it, but this is quickfix
        if (is_string($classes_to_test))$classes_to_test = [$classes_to_test];


        /////
        // load required files & directories
        /////
        foreach ($required_files as $file){
            $path = $dir.'/'.$file;
            // var_dump($path);
            if (is_dir($path)){
                $this->require_directory($path);               
            } else if (is_file($path)){
                (function() use ($path){
                    require_once($path);
                })();
            } else {
                $this->error("File '$file' does not exist in '{$cli->pwd}'");
            }
        }

        /////
        // build array of testable classes
        /////
        $phpFiles = $this->get_php_files($dir, $dir_to_test);

        $excludes = $dirs_to_exclude;
        /** array<string absolute_file_path, string class_name> */
        $test_files = [];
        foreach ($phpFiles as $relPath){
            if ($this->is_excluded($excludes, $relPath))continue;
            $filePath = $dir.'/'.$relPath;
            $class = $this->get_test_class($filePath);
            $class_base = substr($class??'', strrpos($class??'', '\\')+1);
            if (count($classes_to_test)>0&&!in_array($class_base, $classes_to_test))continue;
            $test_files[$filePath] = $class;
        }

        /////
        // Run tests & record results
        /////

        $tests = [];
        foreach ($test_files as $file=>$class){
            if (!is_string($class))continue;
            $results = $this->test_class($class, $args,$cli);
            if ($results===false)continue;

            $info['pass']+=$results['pass'];
            $info['fail']+=$results['fail'];
            $info['disabled']+=$results['disabled'];
            $info['tests_run']+=$results['tests_run'];
            $info['class'][$class] = $results;
            $info['assertions_pass']+=$results['assert_pass'];
            $info['assertions_fail']+=$results['assert_fail'];
            if (count($results['failed_tests']) > 0){
                $info['failed_tests'] .= 
                    "\n**".$class."::   " 
                    .implode(', ', $results['failed_tests']).",";
            }
            // print_r($results['assertion_count']);
        }

        echo "\n";

        ob_start();
        echo "\nTests: ".$info['tests_run'];
        echo "\nPass: ".$info['pass'];
        echo "\nFail: ".$info['fail'];
        echo "\nDisabled: ".$info['disabled'];
        echo "\n\nAssertions Passed: ".$info['assertions_pass'];
        echo "\nAssertions Failed: ".$info['assertions_fail'];
        echo "\n########Tests Failed: ".$info['failed_tests'];

        $results_text = ob_get_clean();

        echo $results_text;
        echo "\n";

        $log_file = $args['file.log_to'] ?? false;
        if ($log_file!==false){
            $fh = fopen($log_file,'a');
            fwrite($fh,
                "\n".date(DATE_RFC2822).": "
                .str_replace("########", "\n\n    ",
                str_replace("\n","  *", $results_text)
                )."\n"
            );
            fclose($fh);
        }

        return $info;
    }

    /**
     * Run tests on a class
     *
     * @param $class string - fully qualified class name
     * @param $args array - cli args + configs
     * @param $cli \Tlf\Cli - cli lib
     *
     * @return array test results as an array
     */
    public function test_class(string $class, array $args,\Tlf\Cli  $cli): array {
        // $ob_level = Utility::startOb();
        //run the test class
        if (!class_exists($class??'',true))return false;
        $tester = new $class($args, $cli);
        $results = $tester->run();
        // $output = Utility::endOb($ob_level);
        return $results;
    }


    /**
     * Get all php files for testing. @see(get_test_class) is used to filter out files that don't contain a test class.
     *
     * @param string $dir the root directory for the project
     * @param array $sub_dirs the sub-directories where files should be searched for
     */
    public function get_php_files(string $dir, array $sub_dirs){
        // find all files that need testing
        $files = [];
        foreach ($sub_dirs as $sub_dir){
            $search_dir = $dir.'/'.$sub_dir;
            $files = array_merge($files, \Tlf\Tester\Utility::getAllFiles($search_dir,$dir,'.php'));
        }
        return $files;
    }

    /**
     * Check if a file is excluded from testing
     *
     * @param $excludes generally, the 'dir.excludes' config
     * @param $relPath the relative path of a file that we're checking for
     * @return bool true or false
     */
    public function is_excluded(array $excludes, string $relPath): bool{
        //check if file is excluded from testing
        foreach ($excludes as $e){
            $re = $relPath;
            if ($re[0]!='/')$re = '/'.$re;
            if ($e[0]!='/')$e = '/'.$e;
            if (substr($relPath,0,strlen($e))==$e)return true;
        }
        if (in_array($relPath, $excludes))return true;

        return false;
    }

    /**
     * Get name of class in file. The class in the file must be a subclass of \Tlf\Tester
     *
     * @param $filePath string the path to the file containing a test class
     * @return class name or null
     * @side_effect require_once the file
     */
    public function get_test_class(string $filePath): ?string {

        (function() use ($filePath){
            require_once($filePath); 
        })();
        $class = Utility::getClassFromFile($filePath);
        if ($class==null){
            $this->report("No class found in $filePath");
            return null;
        }
        if (!is_a($class, '\\Tlf\\Tester', true))return null;
        return $class;
    }

    /**
     * echo the message
     *
     * @param $msg string
     */
    public function report(string $msg){
        echo "\n$msg\n";
    }
}